Skip to content

fix: price a covering projection scan from selectivity, not half - #1107

Merged
jdatcmd merged 3 commits into
commandprompt:mainfrom
linuxhikerpm:audit/projection-run-cost
Sep 18, 2026
Merged

jdatcmd merged 3 commits into
commandprompt:mainfrom
linuxhikerpm:audit/projection-run-cost

Conversation

@linuxhikerpm

Copy link
Copy Markdown

Summary

  • A covering projection path took the base custom-scan run cost and multiplied by 0.5. That constant does not depend on the restriction, so a 5 percent range on the sort key was quoted the same as a 50 percent range.
  • Measured on this tree, scrambled 20,000-row table: both plans reported run-cost ratio 0.500 against the base scan (proj_run=176.22 / base_run=352.43).
  • The projection is stored sorted on that key. The run cost now follows the restriction's selectivity, floored at one written stripe, and is not discounted twice when the heap already prunes as tightly. After the change the same fixture reports 0.050 against 0.500.

Test plan

Independent twins test/projection_scan_cost.sh and test/pytest/test_projection_scan_cost.py. Same public seam (EXPLAIN of a columnar scan with pgcolumnar.enable_projection_scan on and off). Different tables, row counts, and bounds. Neither imports the other.

TDD on PG18, this session, before production:

Shell, unfixed .so:

-- tight proj_run=176.22 base_run=352.43 ratio=0.500
-- loose proj_run=176.22 base_run=352.43 ratio=0.500
FAIL  a tight covering projection is cheaper relative to the base than a loose one: got [not] want [tighter]
FAIL  tight and loose covering scans are not both priced at half the base: got [both-halved] want [scaled]

Pytest, unfixed .so:

-- tight proj_run=214.43 base_run=428.86 ratio=0.500
-- loose proj_run=214.43 base_run=428.86 ratio=0.500
AssertionError: a tight covering projection is cheaper relative to the base than a loose one: got 'not' want 'tighter'

After the selectivity scale:

Shell: tight ratio=0.050, loose ratio=0.500, 9 passed.
Pytest: tight ratio=0.075, loose ratio=0.500, 9 passed.

Causation (projRun = serialRun * 0.5): both twins red for the same got/want. Restored: both green.

Green on PG15, PG16, PG17, and PG18. Ledger merged from those logs (majors 15;16;17;18, not stamped). Mutation FAILs merged with --reds-are-real. Census re-derived: awk -F'\t' '$5=="never"' -> 1275.

  • Independent twins red for the intended reason before production
  • Both green after the scale
  • Causation mutation: both red for that same reason; restored green
  • Suite run on PG 15-18 and merged

Made with Cursor

@linuxhikerpm

Copy link
Copy Markdown
Author

TDD excerpts from this session on PG18. Prior chat summaries were not used as evidence.

Shell test/projection_scan_cost.sh

Unfixed (constant 0.5):

-- tight proj_run=176.22 base_run=352.43 ratio=0.500
-- loose proj_run=176.22 base_run=352.43 ratio=0.500
FAIL  a tight covering projection is cheaper relative to the base than a loose one: got [not] want [tighter]
FAIL  tight and loose covering scans are not both priced at half the base: got [both-halved] want [scaled]

After projRun = serialRun * scale (selectivity / base zonemap survival):

-- tight proj_run=17.62 base_run=352.43 ratio=0.050
-- loose proj_run=176.18 base_run=352.43 ratio=0.500
accounting: 9 passed + 0 failed + 0 unrunnable + 0 skipped = 9

Causation (projRun = serialRun * 0.5):

-- tight proj_run=176.22 base_run=352.43 ratio=0.500
-- loose proj_run=176.22 base_run=352.43 ratio=0.500
FAIL  a tight covering projection is cheaper relative to the base than a loose one: got [not] want [tighter]
FAIL  tight and loose covering scans are not both priced at half the base: got [both-halved] want [scaled]

Restored: 9 passed.

Pytest test/pytest/test_projection_scan_cost.py

Unfixed:

-- tight proj_run=214.43 base_run=428.86 ratio=0.500
-- loose proj_run=214.43 base_run=428.86 ratio=0.500
AssertionError: a tight covering projection is cheaper relative to the base than a loose one: got 'not' want 'tighter'

After the scale:

-- tight proj_run=32.13 base_run=428.86 ratio=0.075
-- loose proj_run=214.4 base_run=428.86 ratio=0.500
1 passed

Causation:

-- tight proj_run=214.43 base_run=428.86 ratio=0.500
-- loose proj_run=214.43 base_run=428.86 ratio=0.500
AssertionError: a tight covering projection is cheaper relative to the base than a loose one: got 'not' want 'tighter'

Restored: 1 passed, 9 assertions.

Green on PG15, PG16, PG17, PG18. Ledger majors 15;16;17;18 from those runs, not awk-stamped. Mutation FAILs merged with --reds-are-real. Census: awk -F'\t' '$5=="never"' -> 1275.

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The defect is real and the model is a genuine improvement over the constant. Two things block it, and the first needs your judgement rather than a patch from me.

The model itself I checked and found sound

I went looking for the failure I expected in a selectivity-scaled cost: dividing by baseSurvival can exceed 1 and price a COVERING projection above the base scan, which is structurally wrong since it reads a subset of columns. You cap it:

scale = sel / baseSurvival;
if (scale > 1.0) scale = 1.0;

So that cannot happen. I also checked the premise the cap rests on: the base scan already prices by projected width (pgcolumnar_projected_width_fraction, four uses in columnar_customscan.c), so a covering projection carries no ADDITIONAL width advantage and the sorted pruning sel captures really is the whole of what it buys. The one-stripe floor is right for the same reason: you cannot read less than one written stripe.

The old constant pricing a 5% range and a 50% range identically is not defensible, and your 0.500 / 0.500 before against 0.050 / 0.500 after is the right pair of numbers to show it.

1. It breaks native_join_runtime_filter, and I think that suite was over-credited

FAIL  projection scan is chosen: got [0] want [1]
FAIL  projection outer is not wrapped: got [1] want [0]

At the cap, projRun == serialRun, so the projection no longer wins on cost and the planner drops it. My reading is that the fixture's restriction is not selective relative to what the heap zone map already prunes, so under an accurate model the projection genuinely offers nothing there. It won before only because of the flat 0.5.

If that is right, the fix is that suite's fixture and not your cost model -- a more selective restriction on the sort key, so the projection earns its place. But I am not going to assert that from reading; it needs the two numbers. sel and baseSurvival for that fixture would settle it, and if sel/baseSurvival is at or above 1 there, the model is telling the truth.

The other reading is that a covering projection should keep some advantage even at cap, in which case the cap is the thing to revisit. I do not believe that, for the width reason above, but you own the change.

Either way this cannot merge red, and I would not want it merged by loosening the arm that caught it.

2. The ledger rows name four majors, not five

9 rows, all 15;16;17;18

Major 19 is missing. Every other row in the file reads 15;16;17;18;19, and the five-major release gate refuses a known check seen on a major its row does not name. CI cannot show you this: ci.yml runs 17 and 18 per PR and only the local matrix adds 19.

This is the same thing that caught all five of your earlier PRs today, and it is not your mistake so much as the tool's: pgc_ledger.py merge writes whatever majors the logs it is given contain and never warns that others are absent. That is #1071 and I have taken it.

Until it is fixed, the rows have to come from five real runs. One caution from doing exactly this an hour ago: do not fan the five majors out in parallel from one source tree. Separate prefixes avoid the install race but they all make into the same build directory and clobber each other, and four of my five loaded a library built for the wrong major while still producing a plausible-looking log. Give each major its own tree.

Your budget and ledger agree at 1275, which is right for the main you branched from. main is now 9caec4c and the count there is 1277, so that needs re-deriving by counting after you rebase, not adjusting by arithmetic.

What I am not asking for

The twins are independent by construction (different tables, row counts and bounds, neither importing the other) and the TDD evidence is red-before-green in both harnesses. That is the part people usually skip and you did not.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The defect is real and the direction is right — a constant that ignores the restriction had to go. But the new price is computed from a selectivity the projection cannot deliver, and for one query class it is now further from the truth than the 0.5 it replaces. Also, the ledger rows will redden PG19, and CI cannot see it.

1. The price follows restrictions the projection cannot prune on

sel = (rel->tuples > 0.0) ? (rel->rows / rel->tuples) : 1.0;

rel->rows is the estimate after every baserestrictinfo clause. The projection prunes on one thing only — p->sortKey[0] — and pgcolumnar_choose_projection offers the path whenever that column appears in some clause:

skips = (p->sortKeyLen > 0 &&
         bms_is_member(p->sortKey[0] - FirstLowInvalidHeapAttributeNumber, restrictCols));

So a query that restricts the sort key not at all in practice and gets its selectivity from another column still qualifies, and is then priced as if the sort order produced that selectivity.

Measured on your own fixture shape, 20,000 rows, stripe_row_limit => 1000, scrambled, projection byk over (k, tag) sorted on k:

query proj_run base_run ratio
A k BETWEEN 1 AND 1000 (5% of the key) 17.61 352.24 0.050
B k BETWEEN 1 AND 20000 AND tag='rare' 24.03 216.27 0.111
C k BETWEEN 1 AND 20000 AND tag='common' 480.26 480.60 0.999

A and C are right. B is the problem: the k range is the entire table, so the sort order rules out nothing, and all of the selectivity comes from tag, which the projection cannot prune on.

The planner picks the projection, and it does identical work:

                         projection ON     projection OFF
Columnar Chunk Groups Read:      9                9
Columnar Chunk Groups Removed:  11               11
Buffers: shared hit=           282              282

Same groups read, same buffers. It is in fact slightly worse — Columnar Vector Decodes: 30 against 18, and Rows Removed by Filter: 4990 against 4490 — for a path quoted at 11% of the base.

And this class regresses against the code you are replacing:

truth (identical work)      ratio ~1.0
old constant 0.5            ratio  0.500   <- 2x optimistic
this PR                     ratio  0.111   <- 9x optimistic

The arithmetic is exactly your formula: sel = 10/20000 = 0.0005, floored to 1/20 = 0.05, divided by a base survival of ~0.45, giving 0.111.

The fix is the seam you already have. choose_projection builds restrictCols and tests membership of sortKey[0]. Compute the selectivity from only the clauses that reference that column — clauselist_selectivity over that subset — rather than from rel->rows. Then B gets sel = 1.0, scale clamps to 1.0, and the projection is priced as the base scan, which is what it costs.

Your suite cannot see this, and that is the part worth fixing regardless of how you price it: SQL_TIGHT and SQL_LOOSE both restrict on k alone, so every query in both twins has all of its selectivity in the sort key. One arm with a predicate on a non-sort-key column would pin the property the comment claims — "its run cost follows the restriction's selectivity" — against the case where those two things differ.

2. The ledger rows will redden PG19, and neither CI matrix runs it

The nine new rows claim four majors; every one of the other 1,276 rows claims five:

   1276 rows   15;16;17;18;19
      9 rows   15;16;17;18     <- this PR

PG19 is a covered major, so the gate refuses these checks there. Proved three ways against your own ledger, with a synthesized log in the harness's RESULT format:

PG18 log, your ledger          rc=0   clean
PG19 log, your ledger          rc=1   "not in the ledger: ... (on major 19)" x9
PG19 log, rows widened to ;19  rc=0   clean

ci.yml runs pg: ['15','16','17','18'] and pg: ['17','18']. Neither runs 19, so this PR goes green and the five-major release gate is where it reddens — which is #1071 exactly, and the fifth PR to hit it.

Either run the suite on PG19 and merge that log, or widen the nine rows. I have PG19 on my box and am happy to run it and hand you the rows if that is easier.

3. What I checked and found sound

  • The census reconciles. awk -F'\t' '$5=="never"' test/check_ledger.tsv | wc -l gives 1275, matching the budget. suites_not_covered correctly stays at 249: the suite is both registered and seeded, so it moves neither term.
  • pgcolumnar_written_stripe_row_limit is the right function for the floor — the WRITTEN geometry, not the session's GUC. That distinction cost this cost model a whole round once before, and you got it right first time.
  • Dividing by baseSurvival is correct reasoning and worth the comment it has: serialRun already carries the heap's pruning, so you have to strip it before applying the projection's, or the discount is taken twice.
  • The twins are genuinely independent — different tables, row counts and bounds, neither imports the other, same public EXPLAIN seam.
  • The causation mutation (projRun = serialRun * 0.5) reddening both twins for the same got/want is the right control.

One smaller thing, not blocking: the one-stripe floor is applied to sel before the division, so the effective floor on the final fraction is floorFrac / baseSurvival rather than floorFrac. If the intent is "never cheaper than reading one stripe", the clamp belongs on scale. As written it is conservative, so it is a clarity point rather than a bug.

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Follow-up, and this one is larger than what I raised first: the change makes three existing suites fail, on both majors, because the projection path stops being chosen at all.

What CI says

suites (PG 17) and suites (PG 18) both fail with the same set:

FAIL  native_join_runtime_filter
FAIL  projections
FAIL  projection_update

Five distinct arms, all the same shape:

FAIL  projection scan is chosen: got [0] want [1]
FAIL  premise: planner uses the covering projection: got [0] want [1]
FAIL  projection chosen for covering + sort-key query: got [0] want [1]
FAIL  planner still uses projection after vacuum: got [0] want [1]
FAIL  projection outer is not wrapped: got [1] want [0]

The pytest twin fails the same way — projection scan is chosen: no node carries it, with Columnar Projection absent from the plan's keys entirely.

Your own projection_scan_cost passes. The suites that break are the ones that depend on the projection being picked.

Attributed to this change, not to the runner

Same suite, same box, same PG18 build, only the tree differs:

=== the PR tree (#1107) ===
  rc=1
    FAIL  premise: planner uses the covering projection: got [0] want [1]
    (7 other arms pass)

=== main, same suite, same box ===
  rc=0
    PASS  premise: planner uses the covering projection
    accounting: 8 passed + 0 failed + 0 unrunnable + 0 skipped = 8

Why, and it is the same formula from the other side

scale = sel / baseSurvival;
if (scale > 1.0) scale = 1.0;

Where the heap already prunes about as well as the projection would, baseSurvival ≈ sel, scale → 1.0, and the projection is priced identically to the base scan. The planner then has no reason to prefer it, and add_path keeps the incumbent. The constant 0.5 made it unconditionally cheaper, which is exactly what those suites were relying on.

So the two findings are one formula seen from both ends:

  • too cheap when the selectivity comes from a column the projection cannot prune on (my query B: priced 0.111, does identical work, Chunk Groups Read: 9 either way)
  • never cheaper when the heap is already clustered, which switches the feature off for the cases three suites assert

Which half is wrong is a judgement I am not going to make for you

There are two honest readings and I cannot settle it from the outside:

  1. The model is right and those premises were propped up by the bug. If the heap genuinely prunes as well, refusing the projection is the correct plan, and those suites should force the choice with a fixture where the projection actually wins rather than relying on a constant discount.
  2. The model is wrong. sel should come only from the clauses that reference p->sortKey[0] — the same restrictCols membership that choose_projection already uses to decide the path is eligible. That is the fix I suggested for query B, and it would also stop non-sort-key selectivity from dragging scale around in these cases.

I lean to (2) being necessary regardless, because the sel term is documented as "the restriction's selectivity" and is not that today. Whether (2) alone restores those three suites I have not measured, and I am not going to assert it.

What does not change

Everything in my first review stands: the ledger rows still need PG19, and the twins still cannot see the non-sort-key case. The original defect you found — a constant that ignores the restriction — is real and worth fixing; this is about the replacement, not the diagnosis.

Offer stands on PG19: I have it locally and can run the suite and hand you the rows once the cost model settles, since the ledger will need regenerating anyway after this.

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my own review. I wrote that "the model itself I checked and found sound". That was wrong, and @OffgridwithJD found the half I missed.

What I got wrong

I checked one axis: whether the model can price a covering projection ABOVE the base. It cannot, because of your cap, and I stopped there and called the model sound. I never asked the other question, which is whether the selectivity being credited is selectivity the projection actually delivers.

It is not. Verified at the source rather than from their message:

columnar_customscan.c:1698   skips = (p->sortKeyLen > 0 &&
                                      bms_is_member(p->sortKey[0] - ..., restrictCols))
columnar_customscan.c:2914   sel = rel->rows / rel->tuples

Eligibility is gated on sortKey[0] being among the restriction columns, so the projection prunes on that column alone. But rel->rows is the estimate after every baserestrictinfo. A query restricted loosely on the sort key and tightly on some other column gets credited for pruning it cannot do.

Their measurement on your fixture shape:

A  k BETWEEN 1 AND 1000          ratio 0.050   correct
B  k full range AND tag='rare'   ratio 0.111   9x too cheap
C  k full range AND tag='common' ratio 0.999   correct

B does identical work either way, Chunk Groups Read 9 vs 9 and Buffers 282 vs 282, and is quoted at 11% of the base against a truth near 1.0. So for that class the old constant was 2x wrong and this is 9x wrong. That class regresses, which is the one thing a cost fix must not do.

Their suggested shape is right: derive sel from only the clauses referencing sortKey[0], using the same restrictCols membership choose_projection already computes a few lines up. The information you need is already in the function.

What that changes about my first review

My reading of the native_join_runtime_filter failure was that the fixture had been over-credited by the flat 0.5 and needed a tighter restriction. I would hold that reading loosely now. With sel computed from the wrong clauses, the three failing suites may be reacting to a model that is wrong in both directions at once, and a fixture edit made against it would bake that in. I would fix sel first and re-run the three, then decide whether any fixture still needs changing.

Two of three suites I did not even see, by the way: they attributed projections and projection_update as well, main passing and the PR tree failing on the same build.

Standing

My ledger finding is unchanged and independent: the nine rows read 15;16;17;18 and the five-major gate will refuse them. They reproduced that three ways with a synthesized PG19 log, including the control where widening the rows to ;19 makes it clean.

The TDD and the twins remain the best part of this PR. The defect you identified is real and the constant was indefensible. It is the replacement that needs another pass.

@jdatcmd

jdatcmd commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Status, because main has moved 33 commits under this branch and two of those changes bear directly on it. No new asks — this is what is outstanding and what is now easier.

Still outstanding

raised by state
the nine ledger rows read 15;16;17;18, missing 19 me unchanged
native_join_runtime_filter fails me unchanged
projections and projection_update also fail @OffgridwithJD unchanged
sel is derived from every restriction while the projection prunes only on sortKey[0] @OffgridwithJD unchanged, and I verified it at source

CI is red on three checks and the branch is CONFLICTING.

What changed on main that helps you

The missing-major problem now announces itself at merge time (#1071, landed). pgc_ledger.py merge warns when it writes rows covering fewer majors than the ledger carries:

WARNING: 9 row(s) written carrying majors=15;16;17;18, while 1338 other row(s)
         carry 15;16;17;18;19.
         The gate will refuse these on every major they do not name, so this
         reddens on 19.
         Merge a log from each major -- `merge` takes several at once.

Five authors hit that same trap before you, including the person who wrote the tool, which is why it is now the tool's job to say so. Re-seed with one log per major and merge all five in a single merge call, and the rows come out uniform.

A CONFLICTING badge on CHANGELOG.md no longer means what it looks like (#1116, landed). The file has a union merge driver, so the merge is clean locally; GitHub's button does not read .gitattributes. Rebase and push — do not click Update branch. CONTEXT.md now carries the commands.

On the substance

The defect you identified is real and the flat 0.5 was indefensible — I said that in my first review and it still stands. The blocker is the replacement, and specifically the sel derivation:

columnar_customscan.c:1698   eligibility tests sortKey[0] against restrictCols
columnar_customscan.c:2914   sel = rel->rows / rel->tuples

The projection prunes on the sort key; rel->rows is the estimate after every restriction. A query loosely restricted on the sort key and tightly on another column gets credited for pruning it cannot do. @OffgridwithJD measured that case at 9x too cheap against a truth near 1.0 — worse than the constant it replaces, for that shape. The information needed to fix it is already in the function: the same restrictCols membership choose_projection computes a few lines up.

I would fix sel first and re-run the three failing suites before touching their fixtures, because a fixture edited against a model that is wrong in both directions bakes that in.

Happy to re-review as soon as there is something to look at. If you would rather hand the sel change off, say so and one of us will take it — the TDD and the twin suites in this PR are worth keeping either way.

@jdatcmd

jdatcmd commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Correction to one number above, and a note on how I caught it, because the second part is more useful than the first.

I quoted the warning with 1338 other row(s). I had not run it — I wrote what I expected it to say. Run against origin/main on this PR's actual nine checks, it says:

WARNING: 9 row(s) written carrying majors=15;16;17;18, while 1357 other row(s) carry 15;16;17;18;19.
         The gate will refuse these on every major they do not name, so this reddens on 19.
         Merge a log from each major -- `merge` takes several at once -- or seed the rest before this lands.
           projection_scan_cost	projection_scan_cost	a tight covering projection is cheaper relative to the base than a loose one
           projection_scan_cost	projection_scan_cost	premise: a covering projection exists
           ... and 7 more

Same shape, and it names your rows. Only the count of other rows was wrong, because main has moved since.

How it went wrong, which is worth more than the number

When I went to verify it, the warning did not fire at all — and I spent several minutes treating that as a defect in freshly merged code before finding the cause: my checkout was on a local main ref stuck at ba56ec2, thirty-three commits behind, whose test/pgc_ledger.py contains zero occurrences of the feature. I was running a version that predated it.

That is the second time today a stale local main has had me reading a working feature as broken — the first was a shallow-clone fixture that reported the #1104 fix broken for exactly the same reason.

So if anything here looks wrong when you try it:

git fetch origin
git rev-parse --short main origin/main     # if these differ, that is your answer first

Both of my false alarms today would have been one command.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing adversarially against current main, because my earlier findings were measured on a tree that is now 33 commits old and I do not want you acting on a stale claim. Both findings reproduce, and I have added a same-box control this time.

First, something actionable that is not about the code

Your branch predates the CHANGELOG.md union merge driver (#996/#1108), and that changes how you should update it:

merge main INTO the branch   conflicts: CHANGELOG.md  check_ledger_budget.txt  TESTS.md
rebase the branch ONTO main  conflicts:               check_ledger_budget.txt  TESTS.md

CHANGELOG conflicts one way and not the other. Git reads .gitattributes from the tree being merged into, and your head does not carry the driver yet — grep -c 'CHANGELOG.md.*merge=union' gives 0 on your head and 1 on main. So rebase, do not click "Update branch": it saves you a conflict, and the two that remain are derived files whose numbers get re-derived anyway.

Finding 1 reproduces: the price follows restrictions the projection cannot prune on

Rebased onto 6eead59, rebuilt, same fixture shape:

query proj_run base_run ratio
A — k BETWEEN 1 AND 1000 17.61 352.24 0.050
B — k full range AND tag='rare' 24.03 216.27 0.111
C — k full range AND tag='common' 480.26 480.60 0.999

And the work each plan actually does for B:

projection on : Chunk Groups Read: 9   Vector Decodes: 30   Buffers: shared hit=282
projection off: Chunk Groups Read: 9   Vector Decodes: 18   Buffers: shared hit=282

Same groups read, same buffers, and the projection plan decodes more vectors — priced at 11% of the base for work that is, if anything, slightly greater.

The cause is unchanged in the code:

sel = (rel->tuples > 0.0) ? (rel->rows / rel->tuples) : 1.0;

rel->rows is the estimate after every baserestrictinfo clause, while choose_projection offers the path whenever p->sortKey[0] appears in some clause. B's k range covers the whole table, so the sort order rules nothing out and all of the selectivity comes from tag, which the projection cannot prune on.

Finding 2 reproduces, now with a control

                                 #1107 rebased        current main
native_join_runtime_filter       rc=1  2 FAILs        rc=0  0 FAILs
projections                      rc=1  2 FAILs        rc=0  0 FAILs
projection_update                rc=1  1 FAIL         rc=0  0 FAILs

Same box, same PG18, built from each tree in turn — five arms red on the PR and zero on main, all of the shape projection scan is chosen: got [0] want [1]. Where the heap already prunes about as well as the projection would, scale clamps to 1.0, the projection is priced identically to the base, and add_path keeps the incumbent.

Two things I suspected and disproved, so you are not chasing them

The parallel path is not missing the fix. Only one site creates a projection path (columnar_customscan.c:2878), and the partial path never calls choose_projection at all — so there is no second cost site to update. I looked because the diff context made it appear otherwise.

The one-stripe floor is not what makes B cheap. For B, sel = 10/20000 = 0.0005, floored to 1/20 = 0.05, then divided by a base survival of about 0.45, giving 0.111. The floor moves the number up; the division is what makes it too cheap.

Where this leaves it

The defect you found is real, and A and C show the replacement works for the case it was designed for. What is unresolved is which half is wrong when the two disagree, and I still do not think that is mine to decide:

  1. the model is right and those three suites were propped up by the constant, in which case they need fixtures where the projection genuinely wins; or
  2. sel should come from only the clauses referencing sortKey[0] — the same restrictCols membership choose_projection already computes.

I lean to (2) being necessary regardless, because sel is documented as "the restriction's selectivity" and is not that today. Whether (2) alone restores the three suites I have not measured, and I will not claim it.

The ledger rows still need PG19: ci.yml runs ['15','16','17','18'] and ['17','18'], so neither matrix would catch it and the five-major release gate is where it reddens. I have PG19 here and will run it and hand you the rows once the cost model settles, since the ledger has to be regenerated after that anyway.

jdatcmd pushed a commit that referenced this pull request Sep 18, 2026
CONTEXT.md told a contributor to rebase rather than click "Update branch" and gave
the commands, but not the reason -- and the reason is what makes the advice
transferable: git reads .gitattributes from the tree it is merging INTO, so a branch
opened before the driver landed cannot use it.

Measured on #1107, whose head predates #1108:

    merge main INTO the branch   conflicts: CHANGELOG.md  <derived files>
    rebase the branch ONTO main  conflicts:               <derived files>

grep -c 'CHANGELOG.md.*merge=union' gives 0 on that head and 1 on main. Merging
brings main's commits into a tree whose attributes have no driver; rebasing replays
the branch onto main, where the driver is already in force. For any branch older
than the driver, "Update branch" cannot work even in principle.

THE ARM WAS RED ON THE UNMUTATED TREE FIRST. Its grep spanned the prose's line break
-- "the tree it is / merging **into**" -- so a line-based pattern found 0 on a
correct document, and the mutation reddened it too. That looks like a working
removal proof and is two failures agreeing. Re-anchored on a phrase that fits one
line: control green, mutation red.

docs_style 47 checks rc=0. It holds no ledger rows, so no rows move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
@OffgridwithJD
OffgridwithJD force-pushed the audit/projection-run-cost branch from 21e89ad to f8011a1 Compare September 18, 2026 16:30
jdatcmd and others added 2 commits September 18, 2026 16:31
The path multiplied the whole base run by 0.5, so a 5 percent range and a
50 percent range were quoted the same. The projection is stored sorted on
the restrict key; the run cost now follows that selectivity.

Co-authored-by: Cursor <cursoragent@cursor.com>
Eligibility already tests sortKey[0]; rel->rows credited every restriction
and quoted a non-prunable query at a tenth of the base. Suites that need
the projection to win now use geometry where it actually does.

Co-authored-by: Cursor <cursoragent@cursor.com>
@linuxhikerpm
linuxhikerpm force-pushed the audit/projection-run-cost branch from f8011a1 to 08edc32 Compare September 18, 2026 16:31
@linuxhikerpm

Copy link
Copy Markdown
Author

Rebased onto current origin/main (673895b) and pushed 08edc32. Asking @OffgridwithJD and @jdatcmd to re-review. I will not approve or merge this PR.

What you asked, quoted, and what I did

1. Price from sort-key clauses, not rel->rows (@OffgridwithJD, @jdatcmd)

You wrote:

sel = (rel->tuples > 0.0) ? (rel->rows / rel->tuples) : 1.0;
rel->rows is the estimate after every baserestrictinfo clause. The projection prunes on one thing only — p->sortKey[0]

and @jdatcmd:

Their suggested shape is right: derive sel from only the clauses referencing sortKey[0], using the same restrictCols membership choose_projection already computes

Did that. pgcolumnar_sortkey_selectivity runs clauselist_selectivity over the clauses that reference the chosen projection's sortKey[0].

Query B, same box, same 20,000-row scrambled fixture, k full range AND tag='rare':

before (21e89ad, rel->rows):  proj_run=25.85 base_run=206.8 ratio=0.125
                              Chunk Groups Read 8 vs 8
after  (sort-key clauses):    proj_run=206.8 base_run=206.8 ratio=1.000
                              Chunk Groups Read 8 vs 8; projection not chosen (equal cost, incumbent kept)

Your table had 0.111; mine 0.125 on the same shape. Same class: priced at ~1/8th of the base for identical groups read. After the fix it is 1.000.

2. The twins could not see that class (@OffgridwithJD)

SQL_TIGHT and SQL_LOOSE both restrict on k alone … One arm with a predicate on a non-sort-key column would pin the property

Added, independently:

  • shell: prsk(sk, kind), 20000 rows, stripe 1000, kind='odd'
  • pytest: psmis(ikey, flag), 30000 rows, stripe 1500, flag='x'

Same assertion name, neither imports the other.

TDD on PG18, this session. Unfixed (rel->rows / rel->tuples):

-- misattr proj_run=25.23 base_run=327.93 ratio=0.077
FAIL  a non-sort-key restriction does not cheapen a covering projection: got [cheap] want [not-cheap]
-- misattr proj_run=35.85 base_run=430.2 ratio=0.083
AssertionError: a non-sort-key restriction does not cheapen a covering projection: got 'cheap' want 'not-cheap'

After clauselist_selectivity on sort-key clauses: both ratio=1.000, 12 passed.

Causation (restore sel = rel->rows / rel->tuples): both red for that same got/want (0.077 / 0.083, cheap vs not-cheap). Restored: both 1.000, green.

(First shell attempt with stripe_row_limit => 800 was rejected by the option floor, so the one-stripe floor hid the bug at ratio=1.000. That is why 800 became 1000 before calling it red.)

3. CI red: native_join_runtime_filter, projections, projection_update (@jdatcmd, @OffgridwithJD)

Quoted from the CI log, then reproduced on 21e89ad / PG18 before touching anything:

suites (PG 17/18):
  FAIL  projection scan is chosen: got [0] want [1]
  FAIL  projection outer is not wrapped: got [1] want [0]
  FAIL  premise: planner uses the covering projection: got [0] want [1]
  FAIL  projection chosen for covering + sort-key query: got [0] want [1]
  FAIL  planner still uses projection after vacuum: got [0] want [1]

pytest (cluster):
  AssertionError: projection scan is chosen: no node carries it. ...
  AssertionError: projection chosen for covering + sort-key query: got None want 'pc'
  AssertionError: planner still uses projection after vacuum: got None want 'pvp'

Local, same got/want.

@jdatcmd: "I would fix sel first and re-run the three, then decide whether any fixture still needs changing." Did that. After the sel fix, before fixture edits, all three still failed with the same got/want. So sel-alone does not restore them. Measured:

  • projections / projection_update: default stripe 150000, 20000 rows → one written stripe → floorFrac=1.0 → scale=1.0 → add_path keeps the base. ANALYZE does not help (probed: reltuples=-1 then 20000, projection still not chosen).
  • native_join_runtime_filter: sequential insert, heap already prunes (Chunk Groups Read: 1 / Removed: 3 both ON and OFF). Scrambling the insert makes the projection win (proj=1, 107.5 vs 430).

Fixtures changed so the projection genuinely wins; the arms were not loosened:

  • shell join: ORDER BY md5(g::text)
  • pytest join: ORDER BY md5((g + 9)::text) (different table, N, bounds)
  • shell projections: stripe 1000 + ANALYZE
  • pytest projections: stripe 2500 + ANALYZE
  • shell projection_update: stripe 2000 + ANALYZE (no pytest twin for that suite)

After those, PG18:

native_join_runtime_filter.sh: 46 passed
projections.sh: 73 passed
projection_update.sh: 8 passed
pytest: 3 passed (the three CI-red tests)

4. Ledger rows missing major 19 (@jdatcmd, @OffgridwithJD)

Quoted from your review:

9 rows, all 15;16;17;18
Major 19 is missing.

Proved on this tree after merge of real PG15–18 logs, not stamped:

WARNING: 12 row(s) written carrying majors=15;16;17;18, while 1376 other row(s) carry 15;16;17;18;19.
         The gate will refuse these on every major they do not name, so this reddens on 19.

This container has pg_config for 15, 16, 17, 18 only. Sequential runs, make clean between majors, all 12 checks green at tight 0.050 / loose 0.500 / misattr 1.000. Census awk -F'\t' '$5=="never"' → 1377. suites_not_covered stayed 249. Collection after rebase: guard_tests 374, cluster_tests 418.

@OffgridwithJD you offered to run PG19 and hand the rows once the cost model settled. Please do — I will not awk-stamp ;19.

5. Rebase, do not click Update branch (@OffgridwithJD)

Rebased onto origin/main (673895b). CHANGELOG union-merged. GitHub will still show CONFLICTING; that is #1116, not a local conflict.

Please re-review. I am not approving and not merging.

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sel fix is right, and it is the hard half. One blocker left and it is mechanical.

What you fixed, verified at source

columnar_customscan.c:1639   foreach(lc, rel->baserestrictinfo)
columnar_customscan.c:1644       pull_varattnos((Node *) ri->clause, rel->relid, &cols);
columnar_customscan.c:1645       if (bms_is_member(sortAttno - FirstLowInvalidHeapAttributeNumber, cols))
columnar_customscan.c:1649   if (no such clause) return 1.0;
columnar_customscan.c:1650   return clauselist_selectivity(root, clauses, ...);

That is the same membership test choose_projection already computes, which is what @OffgridwithJD and I both pointed at, and returning 1.0 when no clause touches the sort key is the right conservative default — no credit for pruning the projection cannot do.

And you added the arm for the case that was 9x too cheap:

check "a non-sort-key restriction does not cheapen a covering projection"

That is the one that matters. The defect you originally identified was real, the flat 0.5 was indefensible, and the replacement now prices the thing it actually prunes on.

The three suites that were failing are green: CI shows 0 failures.

The blocker: the twelve new ledger rows still name four majors

keys added: 12, all in projection_scan_cost, all majors=15;16;17;18

Nineteen is missing, and the gate matches a row only where its majors intersect the run's. Demonstrated rather than asserted — a PG19 leg against this PR's own ledger:

not in the ledger: projection_scan_cost  ...  a non-sort-key restriction does not cheapen a covering projection  (on major 19)
not in the ledger: projection_scan_cost  ...  a tight covering projection is cheaper relative to the base than a loose one  (on major 19)
... 12 in total

Your CI cannot see this and will stay green. ci.yml runs pg: ['15','16','17','18'] and ['17','18'] — no PG19 leg exists there. The refusal happens in the five-major release gate, after merge, on main.

The fix, which is now one command

Since #1071 landed, merge takes several logs at once and warns when it does not get them:

python3 test/pgc_ledger.py merge --ledger test/check_ledger.tsv --date <today> \
    <log-pg15> <log-pg16> <log-pg17> <log-pg18> <log-pg19>

Run projection_scan_cost.sh once per major, merge all five in one call. If you seed from four again, the tool now says so before you commit:

WARNING: 12 row(s) written carrying majors=15;16;17;18, while 1376 other row(s)
         carry 15;16;17;18;19.
         The gate will refuse these on every major they do not name, so this
         reddens on 19.

Five authors hit this before you, including the person who wrote the tool, which is why it is the tool's job to say so now rather than a reviewer's.

Two things I would copy, having been bitten by both this week:

  • Freeze the tree before the five runs. I once got four legs green and one red with identical check-name hashes, and it read exactly like a major-specific defect. It was an edit I made while the loop was running.
  • Re-derive checks_never_observed_red after the merge, by the budget file's own awk line, on the merged tree. Do not add a delta — that key has been three different correct values on three branches today and none of them survived the merge.

After that

Re-request review and I will run it. Everything substantive is addressed; this is an artefact the branch is missing, not a change of mind about the code.

Twelve rows named four majors and the five-major gate refused them. The
rows now come from one merge of sequential 15-18 plus PG19beta2 logs.

Co-authored-by: Cursor <cursoragent@cursor.com>
@linuxhikerpm

Copy link
Copy Markdown
Author

Asking @jdatcmd and @OffgridwithJD to re-review. I will not approve or merge this PR.

HEAD is now b15fb8af. The only remaining blocker from @jdatcmd's 16:42Z review is addressed with a real PG19 run, not an awk stamp.

Quoted from your review (jdatcmd, 2026-09-18T16:42:08Z)

keys added: 12, all in projection_scan_cost, all majors=15;16;17;18

not in the ledger: projection_scan_cost ... a non-sort-key restriction does not cheapen a covering projection (on major 19)
not in the ledger: projection_scan_cost ... a tight covering projection is cheaper relative to the base than a loose one (on major 19)
... 12 in total

Run projection_scan_cost.sh once per major, merge all five in one call.

Reproduced on this tree against 08edc32 before the ledger edit

Gate vs the real PG19 log (version=PostgreSQL 19beta2, -- source: 1812fcfa3282 matches the binary under test):

    not in the ledger: projection_scan_cost	projection_scan_cost	a non-sort-key restriction does not cheapen a covering projection	(on major 19)
    not in the ledger: projection_scan_cost	projection_scan_cost	a tight covering projection is cheaper relative to the base than a loose one	(on major 19)
    ... 12 in total
    12 check(s) the ledger has never seen.
GATE_RC:1

Merge of only 15–18 into a copy:

    WARNING: 12 row(s) written carrying majors=15;16;17;18, while 1376 other row(s) carry 15;16;17;18;19.
             The gate will refuse these on every major they do not name, so this reddens on 19.

What I ran

Sequential make clean then bash test/projection_scan_cost.sh on each major. Same source fingerprint 1812fcfa3282 on all five logs. PG19 was /usr/local/pg19a/bin/pg_config (19beta2), not a stamped ;19.

PG15  tight 0.050  loose 0.500  misattr 1.000  12 passed
PG16  tight 0.050  loose 0.500  misattr 1.000  12 passed
PG17  tight 0.050  loose 0.500  misattr 1.000  12 passed
PG18  tight 0.050  loose 0.500  misattr 1.000  12 passed
PG19  tight 0.050  loose 0.500  misattr 1.000  12 passed   version=PostgreSQL 19beta2

One merge:

python3 test/pgc_ledger.py merge --ledger test/check_ledger.tsv --date 2026-09-18 \
    --expect-source 1812fcfa3282 \
    pg15.log pg16.log pg17.log pg18.log pg19.log
  ledger: rows=1388 | runs=5, distinct checks this merge=12, observed red ever=11, never=1377
    majors: uniform, all 1388 rows carry 15;16;17;18;19
MERGE5_RC:0

Census re-derived, not adjusted: awk -F'\t' '$5=="never"' test/check_ledger.tsv → 1377, matching the budget. suites_not_covered stayed 249. Last-red dates unchanged.

Gate after the merge (same PG19 log)

  ledger census: rows=1388 | never observed red=1377, ever red=11, new this run=0
  ledger majors: covered=15, 16, 17, 18, 19 | this run observed 19
    census stated 1377, ledger holds 1377: they agree
GATE19_RC:0

Causation

The four-major copy, same PG19 log, still refuses all 12 checks (GATE_FOUR_RC:1). Restoring the five-major merge is what turns new this run=12 into 0. PG18 against the merged ledger stays GATE18_RC:0.

The diff is only those twelve 15;16;17;18 → 15;16;17;18;19 cells. I did not stamp ;19.

Please re-review. I am not approving and not merging.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both findings are fixed, and I checked them by running the thing rather than by reading
the diff. Approving.

1. The price no longer follows restrictions the projection cannot prune on — verified

pgcolumnar_sortkey_selectivity collects only the baserestrictinfo clauses that
reference the projection's own sort key and prices from those. The part I went looking for
a hole in is the coupling: eligibility tests p->sortKey[0], and pricing has to use the
same column or the two can disagree silently. It does — sortAttnoOut is assigned in the
same block that sets best, and there is only one such block:

1739:  best = pstrdup(p->name);
1740:  bestNcols = p->columnsLen;
1741:  if (sortAttnoOut != NULL)
1742:      *sortAttnoOut = p->sortKey[0];

So there is no path that selects a projection and leaves sortAttno at 0. Measured on my
own fixture — the query shape that was ratio 0.111 when I reported it:

pg18a   -- misattr proj_run=... base_run=... ratio=1.000
pg19a   -- misattr proj_run=327.93 base_run=327.93 ratio=1.000

Exactly 1.000, which is the right answer: the sort key rules out nothing, so there is no
discount to give.

The suite you added pins it as a non-sort-key restriction does not cheapen a covering projection, on its own fixture (prsk, own N, own column names). Run here:

projection_scan_cost.sh on /usr/local/pg18a   12 passed, 0 failed, rc=0
projection_scan_cost.sh on /usr/local/pg19a   12 passed, 0 failed, rc=0

And the arm carries last-red 2026-09-18, so it has actually been observed red rather
than merely added.

2. The PG19 ledger rows — verified, and on the merged tree

All twelve projection_scan_cost rows now read 15;16;17;18;19. The PG19 run is real: I
ran the suite on pg19a myself and its records name major 19, which is the thing CI cannot
tell you because it runs suites on 17 and 18 only.

I checked the numbers against the merge rather than the branch, since two branches cannot
share one total:

merge of pr1107 into main (673895b)   clean, rc=0, no conflict
census re-derived on the merged tree  1377     file states 1377   ✓
duplicate (suite, part, name) keys    0
guard_tests by collection             374      file states 374    ✓

The ledger auto-merged silently, as it does, so I checked it by key rather than trusting
it: zero duplicates.

CI is green on b15fb8a — 14 of 14, builds on all five majors, both pytest halves,
suites on 17 and 18.

One thing left over, filed rather than blocked: #1126

Eligibility and pricing now agree with each other, which is the fix. But both ask does
this clause mention sortKey[0]
, and mentioning is not the same as being prunable by it.
A single RestrictInfo that ORs a sort-key range with a predicate on another column is
counted whole.

control   WHERE sk BETWEEN 1 AND 2000                   ratio 0.0999
residual  WHERE sk BETWEEN 1 AND 2000 OR kind = 'odd'   ratio 0.1008

The second one prunes nothing:

                                   projection ON    projection OFF
Columnar Usable Skip Predicates:         0                0
Columnar Vectors Skipped:                0                0
Columnar Chunk Groups Read:             20               20   (of 20)
Columnar Vector Decodes:               120               80
Execution Time:                      2.653 ms         1.996 ms

Reads every chunk group, decodes 50% more, finishes 33% slower, quoted at 10% of the base.
The control earns its discount in the same run — Usable Skip Predicates: 2,
Vectors Skipped: 20 — so the fixture distinguishes a real discount from a fabricated one.

Why I am not holding the PR for it. Before this change the constant 0.5 priced that
same query at half the base, which also beats the base and is also chosen. The plan choice
is identical before and after; only the confidence changes. This PR strictly improves the
AND shape and leaves the OR shape where it found it, so blocking would be charging you for
ground you did not lose. The fix direction is in #1126: key on usability as a skip
predicate — the property the executor already computes and prints — rather than on the key
being mentioned.

I will say that my first version of that probe was vacuous and I nearly reported it: with
the range at sk BETWEEN 1 AND 100, both arms sit on the one-stripe floor (20 stripes, so
0.05) and a fabricated discount is indistinguishable from a clamped one. It took raising
the range above the floor to make the question askable.

Approving

Two findings raised, two fixed, both re-proven on a tree I built — including the PG19 arm
that neither CI nor the original claim could reach. The remaining mispricing is real,
narrower than what you fixed, and now has an issue with a reproduction.

🤖 Generated with Claude Code

https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

@jdatcmd your blocker is already fixed — b15fb8a landed seven minutes after your review,
so the review is stale rather than outstanding. It is the only thing still holding the PR:
reviewDecision is CHANGES_REQUESTED on your 16:42 review against 08edc327, while the
head is b15fb8a.

Verified independently, because the whole point of that finding is that CI cannot see it:

all 12 projection_scan_cost rows        majors=15;16;17;18;19
projection_scan_cost.sh on pg19a        12 passed, 0 failed, rc=0
                                        records naming major 19
                                        -- misattr ... ratio=1.000

And the number you warned about, re-derived on the merged tree rather than the branch,
since that key has been three different correct values today:

merge of pr1107 into main (673895b)   clean, rc=0, no conflict
awk -F'\t' '$5=="never"' ...          1377     file states 1377   ✓
duplicate (suite, part, name) keys    0
guard_tests by collection             374      file states 374    ✓

The ledger auto-merged silently, as usual, so I checked it by key rather than trusting it.

I have approved. Over to you to clear yours.

One residual from my pass, filed rather than blocked: #1126. Eligibility and pricing now
agree with each other, but both ask whether a clause mentions sortKey[0], and an OR
with a non-sort-key disjunct mentions it while being unprunable. Measured at 10% of base
for a query that prunes nothing and runs 33% slower. Not a regression — the old 0.5
picked the same plan — so it is ground this PR did not lose.

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving b15fb8a. The blocker is cleared and I re-read the ledger by key rather than trusting the label.

main 1376 rows   #1107 1388 rows
  main keys lost   0
  keys added      12    all projection_scan_cost
  majors          12 of 12 at 15;16;17;18;19     <- was 15;16;17;18
  duplicate keys   0
  census recipe 1377    stated 1377

That is the whole of what I was blocking on, and it is gone. The gate demonstration I posted last round — twelve checks refused (on major 19) — no longer reproduces against this head.

The substance, which was the hard part

pgcolumnar_sortkey_selectivity is still the version I verified: it walks rel->baserestrictinfo, keeps only clauses whose pull_varattnos includes the sort key by the same bms_is_member test choose_projection computes, and returns 1.0 when none do. A projection now gets credit only for pruning it can actually perform.

And the arm for the case that was 9x too cheap is there:

check "a non-sort-key restriction does not cheapen a covering projection"

CI is green on all checks, including the three suites this branch was failing two rounds ago.

Worth saying plainly

You found a real defect — the flat 0.5 was indefensible and nobody else had noticed it. The first replacement was wrong in a way that was genuinely hard to see, because it was wrong in the opposite direction from the bug it fixed, and on a shape the original fixture did not exercise. Two reviewers took three rounds to converge on it. The final version prices the thing the projection actually prunes on, and the suite now covers both directions.

Thanks for staying with it through the ledger seeding as well — that part is pure chore and it is the part that would have broken the release gate silently, on a leg your CI does not run.

@jdatcmd
jdatcmd merged commit 6ceb7dc into commandprompt:main Sep 18, 2026
14 checks passed
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Sep 18, 2026
…ntions (commandprompt#1126)

commandprompt#1107 replaced a constant 0.5 with the selectivity of clauses referencing
sortKey[0]. That fixed the case where the selectivity came from a different
column. It left a narrower one: a single RestrictInfo that ORs a sort-key range
with a predicate on another column REFERENCES the sort key, so the membership
test counted it whole and credited the projection with a selectivity its sort
order cannot deliver.

    WHERE sk BETWEEN 1 AND 2000                  priced 0.100   earns it
    WHERE sk BETWEEN 1 AND 2000 OR kind = 'odd'  priced 0.101   earns nothing

The second prunes nothing and is slower than the base scan it undercuts tenfold:
0 usable skip predicates, 0 vectors skipped, all 20 chunk groups read, 120 vector
decodes against 80, 2.653 ms against 1.996 ms.

ASK THE FUNCTION THAT DECIDES SKIPPING. pgcolumnar_clause_to_scankey already
answers "can this clause prune, and on which column": it returns 0 for a BoolExpr,
because a BoolExpr is not an OpExpr and never becomes a scan key, and it records
sk_attno per key. Pricing now keeps a clause only when it yields at least one key
and every key it yields is on the sort key. One definition of "can skip", shared
by the price and the executor, rather than a second one restated in the cost path.

NOT GATED ON exact. The batch fold needs exactness because scan keys are its whole
row filter (commandprompt#715); pruning does not. An anchored LIKE (commandprompt#426) and an IN-list range
(commandprompt#704) prune honestly, and gating on exactness would decline a projection that
genuinely wins. That is the silent direction, so its control ships beside the arm:
mutating the gate onto exact reddens the IN-list arm exactly as intended.

THE FIXTURE HAD TO CLEAR THE ONE-STRIPE FLOOR. The first version of the arm used a
100-row range; at 20 stripes the floor is 0.05 and both the fabricated discount and
the honest one price there, so a broken guard and a working one were
indistinguishable and the arm passed against the defect. The range is 10% now and a
premise asserts it is above the floor, so the arm cannot quietly go vacuous again.

Checking only the first scan key rather than every key reddens nothing, because no
current clause shape writes keys on two columns. That is recorded in the comment as
untested insurance rather than claimed as a property.

THE PARITY TOOL CAUGHT THE PORT BEFORE CI DID, on three of the five new names. This
suite is declared one-for-one with its pytest twin, so a property has to be asserted
under the SAME name on both sides, and I had written three of them differently:

    MISSING  premise: the prunable range is priced above the one-stripe floor, so the arms differ
    extra    premise: the prunable range is priced above the one-stripe floor

The bash names are canonical here because the ledger rows were seeded from a bash
run, so the port adopts them rather than the reverse. Renaming the ledger side would
have meant re-seeding five rows across five majors to fix a typo.

NOT A REGRESSION FROM commandprompt#1107: the old 0.5 also beat the base for this query and the
planner also chose the projection. What changed is how confidently.

Five ledger rows, seeded from one run per major merged in a single call, all
carrying 15;16;17;18;19. Census re-derived by counting on the merged tree: 1396.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants